LINE の友だち登録経路を拾える仕組みを考えてみた
こんにちは、高崎@アノテーションです。
はじめに
LINE 公式アカウントには、友だち登録(もしくはブロック解除)の経路がオーディエンスとして管理されています(下記参照)。
公式アカウントの提供者側で管理したり、登録経路をカスタマイズしたい場合に備え、Messaging API と LIFF ミニアプリを使った管理方法を検討しましたので記事にいたします。
基本的な考え方
先日投稿した記事(友だち登録とブロック解除を区別出来るようになったので試してみた)に記載しましたが、トーク画面での送信以外に、友だち登録やブロック、およびブロック解除のイベントにおいても Messaging API で登録した Webhook URL がコールされます。
一方、友だち登録やブロック解除を行う際は、下記に示した URL をコールすることで行われます。
https:/R/ti/p/ボットのベーシックID
この URL にクエリパラメータを付加させて Webhook URL にもクエリパラメータが引き継がれるのであれば問題ないのですが、Webhook の先にはパラメータが引き継がれません。
そこで、以下のような構成を考えました。
- 経路と登録する LINE ユーザー情報を連携する必要があるので、ユーザー情報を取得できる LINE ミニアプリを使用する
- LINE ミニアプリから取得したユーザー情報と登録経路を渡して Webhook からもアクセス可能なところに保持させる API を新設する(名称を
register
とする) - 保持する先は DynamoDB にして、LINE ミニアプリから API Gateway を介して Lambda を動作させて保存する
- DynamoDB への保存後、ミニアプリから友だち登録(ブロック/ブロック解除)を行う URL をコールして Webhook のイベント処理で登録経路があるか判断する
構成図にまとめますと下記になります。
シーケンスを書いてみる
簡単なシーケンスを記載しました。
青色で囲っている箇所ですが、register
API に LINE のユーザ ID を直接渡すやり方は LINE にて推奨されていないため、トークンを渡すようにしてユーザ情報を取得するようにしています。
参考:
LIFFアプリおよびサーバーでユーザー情報を使用する
【LIFF】LINEのユーザー情報をサーバーサイドで使用する際のアンチパターンと適切な実装方法のご紹介
ベースソース
ベースソースはそれぞれ下記を使用します。
クライアント側のベースソース。
サーバー側のベースソース。
クライアント側の実装
ボタンを1つ追加し、押下イベントで register API をコールして、LINE の友だち登録(ブロック解除)の URL をコールする形で実装します。
ソースはたたんでおきます。
:
-const App = () => {
+interface AppProps {
+ paramHomeScreen: string;
+ paramRegisteredSource: string;
+}
+export const App: FC<AppProps> = ({ paramHomeScreen, paramRegisteredSource }) => {
// 開始メッセージ構築
// クエリーパラメータ有り、かつ URL 指定起動(更新や戻るで移動していない)のものを選別
const startMessage = (paramHomeScreen !== "" && isNavigateAccess()) ?
"Started from home screen..." :
"Started from direct access...";
const [message, setMessage] = useState(startMessage);
const [error, setError] = useState("no error");
const handleClick = () => {
// ショートカット URL 作成(クエリーパラメータ付与)
liff.createShortcutOnHomeScreen({ url: `https://miniapp.line.me/${import.meta.env.VITE_LIFF_ID}?createHomeScreen=1` })
.then(() => {
setMessage("createShortcutOnHomeScreen succeeded.");
})
.catch((e: Error) => {
console.log(e);
setMessage("createShortcutOnHomeScreen failed.");
setError(`${e}`);
});
}
+
+ const handleRegisterClick = async () => {
+ // LINE アクセストークン取得
+ const lineAccessToken = liff.getAccessToken();
+ // あった場合(ない場合はログインされていないので何もしない)
+ if (lineAccessToken) {
+ try {
+ const responsePreRegister = await axios.post(`${import.meta.env.VITE_PREREGISTER_API_URL}`, {
+ lineAccessToken, registeredSource: paramRegisteredSource
+ });
+ if (responsePreRegister.status === 200) {
+ // LINE 友だち登録(ブロック解除)起動
+ liff.openWindow({
+ url: `${import.meta.env.VITE_REGISTER_LINE_URL}`,
+ external: false
+ });
+ } else {
+ console.log(responsePreRegister);
+ throw ("preregister error!");
+ }
+ } catch (e) {
+ console.log("error catch!!!");
+ if (e instanceof Error) {
+ console.log(e);
+ setError(e.message);
+ }
+ }
+ } else {
+ if (lineAccessToken == null) {
+ setError("I don't have a LINE Access Token.");
+ } else {
+ setError("You are already friends.");
+ }
+ }
+ }
return (
<div className="App">
<h1>Liff Lambda Test App</h1>
{message && <p>{message}</p>}
<p>
<button onClick={handleClick}>リンクを作成</button>
+ <button onClick={handleRegisterClick}>LINE へ友だち登録</button>
</p>
{error && (
<p>
<code>{error}</code>
</p>
)}
</div>
);
}
:
サーバー側の追加実装
現状のディレクトリツリーは下記になりますが、ベースソースは DDD に基づいて実装していますので、設計変更も合わせて実践します。
/
└─ backend
└─ src
├─ di-container
│ └─ register-container.ts
├─ domain
│ ├─ model
│ │ ├─ imageCraft
│ │ │ └─ imageCraft-repository.ts
│ │ └─ memoStore
│ │ ├─ memoStore-repository.ts
│ │ └─ memoStore.ts
│ └─ support
│ └─ line-bot
│ └─ line-bot.ts
├─ handler
│ └─ line-bot
│ └─ line-bot.ts
├─ infrastracture
│ ├─ line-bot
│ │ └─ line-bot-impl.ts
│ └─ repository
│ ├─ imageCraft-bedrock-s3-repository.ts
│ └─ memoStore-dynamodb-repository.ts
└─ use-case
└─ line-bot-use-case
├─ dispatchCommand.ts
└─ use-case.ts
DynamoDB 定義・制御
登録情報を保持する内容については下記のテーブル FriendRegister
を用意します。
項目 | 説明 | キー | 型 | 備考 |
---|---|---|---|---|
lineUserId | 保持している LINE ユーザー ID | PK | string | |
registerFrom | 保持しておく登録経路 | string | ||
registerStatus | 登録状態 友だち登録待ち:READY 友だち登録済み:FRIEND FRIEND REGISTERED |
string |
テーブル名については、元のソースで扱っているやり方と同様に環境変数として用意し、IaC にて登録するようにします。
DynamoDB を扱うため、以下のソースを用意します。
- データ定義・・・backend/src/domain/model/friendRegister.ts
- リポジトリ定義(スーパークラス)・・・backend/src/domain/model/friendRegister-repository.ts
- リポジトリ制御(DynamoDB)・・・backend/src/infrastracture/repository/friendRegister-dynamodb-repository.ts
ソースはたたんでおきます。
export interface FriendRegister {
lineUserId: string
registerFrom: string
registerStatus: string
}
export type FriendRegisters = ReadonlyArray<FriendRegister>;
import { FriendRegister, FriendRegisters } from "./friendRegister";
export type FriendRegisterResult = FriendRegister | undefined;
export type FriendRegistersResult = FriendRegisters;
export interface FriendRegisterRepository {
getAll(): Promise<FriendRegistersResult>;
getItemFromId(lineUserId: string): Promise<FriendRegisterResult>;
putItem(item: FriendRegister): Promise<void>;
deleteItem(lineUserId: string): Promise<void>;
}
import {
DynamoDBDocumentClient,
ScanCommand,
QueryCommand,
PutCommand,
DeleteCommand
} from "@aws-sdk/lib-dynamodb";
import { FriendRegister } from "@/domain/model/friendRegister/friendRegister";
import {
FriendRegisterResult,
FriendRegistersResult,
FriendRegisterRepository
} from "@/domain/model/friendRegister/friendRegister-repository";
export class FriendRegisterDBRepository implements FriendRegisterRepository {
private readonly dbDocument: DynamoDBDocumentClient;
private readonly friendRegisterTableName: string;
constructor({
dbDocument,
friendRegisterTableName,
}: {
dbDocument: DynamoDBDocumentClient,
friendRegisterTableName: string,
}) {
this.dbDocument = dbDocument;
this.friendRegisterTableName = friendRegisterTableName;
}
private setItem(item: Record<string, any>): FriendRegister {
const element: FriendRegister = {
lineUserId: item["lineUserId"] || "",
registerFrom: item["registerFrom"] || "",
registerStatus: item["registerStatus"] || "",
};
return element
}
async getAll(): Promise<FriendRegistersResult> {
const command = new ScanCommand({
TableName: this.friendRegisterTableName,
});
const { Items: items = [] } = await this.dbDocument.send(command);
const scanItems: FriendRegister[] = [];
items.map((item) => {
scanItems.push(this.setItem(item));
});
return scanItems;
}
async getItemFromId(lineUserId: string): Promise<FriendRegisterResult> {
const command = new QueryCommand({
TableName: this.friendRegisterTableName,
KeyConditionExpression: "lineUserId = :userid",
ExpressionAttributeValues: { ":userid": lineUserId },
ScanIndexForward: false,
});
const { Items: items = [] } = await this.dbDocument.send(command);
if (items.length === 0) {
return undefined;
}
return this.setItem(items[0]);
}
async putItem(item: FriendRegister): Promise<void> {
const command = new PutCommand({
TableName: this.friendRegisterTableName,
Item: {
lineUserId: item.lineUserId,
registerFrom: item.registerFrom,
registerStatus: item.registerStatus,
},
});
try {
const res = await this.dbDocument.send(command);
console.log("putcommand res : ", res);
} catch (error) {
console.error("putItem :", error);
}
}
async deleteItem(lineUserId: string): Promise<void> {
const command = new DeleteCommand({
TableName: this.friendRegisterTableName,
Key: {
lineUserId: lineUserId,
},
});
try {
await this.dbDocument.send(command);
} catch (error) {
console.error("deleteItem :", error);
}
}
}```
API ハンドラとユースケース
ハンドラーについては、register API のハンドラーを IaC にも定義します。
export class LineBotTestStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
:
// API Gateway の作成
// MemoBot 用
const api = new apigateway.RestApi(this, "LineMemoApi", {
restApiName: "LineMemoApi",
});
+ const lineWebhook = api.root.addResource("linewebhook");
// proxy ありで API Gateway に渡すインテグレーションを作成
- const lambdaInteg = new apigateway.LambdaIntegration(lambdaMemoBot, {
+ const lambdaIntegMemoBot = new apigateway.LambdaIntegration(lambdaMemoBot, {
proxy: true,
});
+ const lambdaIntegLiffApp = new apigateway.LambdaIntegration(lambdaLiffApp);
// API Gateway の POST イベントと Lambda との紐付け
- api.root.addMethod("POST", lambdaInteg);
+ lineWebhook.addMethod("POST", lambdaIntegMemoBot);
+ // Liff App 用
+ const register = api.root.addResource("register");
+ register.addMethod("POST", lambdaIntegLiffApp);
:
LINE ボットの Webhook と、今回追加する register とを同じ API Gateway の URL からリソースを追加して定義するようにします。
ハンドラと
ハンドラとユースケースは下記で定義します。
- ハンドラ・・・backend/src/handler/liff-app/handler-register.ts
- ユースケース・・・backend/src/use-case/liff-app-use-case/use-case.ts
ソースはたたんでおきます。
import { ID_REGISTER_API_USE_CASE, initContainer } from "@/di-container/register-container";
import { RegisterApiUseCase, UnexpectedError } from "@/use-case/liff-app-use-case/use-case";
import { APIGatewayProxyResult, APIGatewayProxyEvent, Context } from "aws-lambda";
const resultOK: APIGatewayProxyResult = {
statusCode: 200,
body: JSON.stringify({}),
}
const resultError: APIGatewayProxyResult = {
statusCode: 500,
body: JSON.stringify({}),
}
export const handler = async (event: APIGatewayProxyEvent, context: Context) => {
console.log("event : ", event);
console.log("context : ", context);
const container = initContainer();
const { lineAccessToken, registeredSource } = JSON.parse(event.body!);
console.log("parameters : ", lineAccessToken, ", ", registeredSource);
const execRegisterApiUseCase = container.get<RegisterApiUseCase>(ID_REGISTER_API_USE_CASE);
const resultUseCase = await execRegisterApiUseCase(lineAccessToken || "", decodeURIComponent(registeredSource || ""));
if (resultUseCase instanceof UnexpectedError) {
console.error("予期しないエラーが発生");
return resultError;
}
return resultOK;
}
import { FriendRegisterRepository } from "@/domain/model/friendRegister/friendRegister-repository";
import axios from "axios";
export class UnexpectedError extends Error { }
export type RegisterApiUseCaseResult =
| void
| UnexpectedError;
const DEF_LINE_API_VERIFY_URL = "https://api.line.me/oauth2/v2.1/verify";
const DEF_LINE_API_PROFILE_URL = "https://api.line.me/v2/profile";
export type RegisterApiUseCase = (
lineAccessToken: string,
registerFrom: string,
) => Promise<RegisterApiUseCaseResult>;
export const execRegisterApiUseCase = ({
friendRegisterRepository,
}: {
friendRegisterRepository: FriendRegisterRepository,
}): RegisterApiUseCase => async (lineAccessToken: string, registerFrom: string): Promise<RegisterApiUseCaseResult> => {
if (lineAccessToken === "") {
console.log("LINE Access Token is null.");
return new UnexpectedError();
}
try {
const verifyResponse = await axios.get(DEF_LINE_API_VERIFY_URL, {
params: { access_token: lineAccessToken },
});
if (verifyResponse.status === 200) {
console.log("call profile");
const userInfoResponse = await axios.get(DEF_LINE_API_PROFILE_URL, {
headers: {
Authorization: `Bearer ${lineAccessToken}`,
}
});
const lineUserId = userInfoResponse.data.userId;
await friendRegisterRepository.putItem({
lineUserId,
registerFrom,
registerStatus: "READY",
});
} else {
console.log("not 200 response : ", verifyResponse);
return new UnexpectedError();
}
} catch (err) {
if (err instanceof Error) {
console.log("err : ", err);
}
return new UnexpectedError();
}
return undefined;
}
次項で説明しますが、バインドしている DI コンテナからユースケースの関数を取得してコールしています。
DI コンテナの実装
コンテナに格納するのは以下の3つです。
- DynamoDB のテーブル名(環境変数に設定しているので)
- DynamoDB を扱うリポジトリ
- LIFF からコールされる API のユースケース
ソースはたたんでおきます。
:
import { execLineBotUseCase } from '@/use-case/line-bot-use-case/use-case';
+import { FriendRegisterDBRepository } from '@/infrastracture/repository/friendRegister-dynamodb-repository';
import { execRegisterApiUseCase } from '@/use-case/liff-app-use-case/use-case';
:
export const ID_TABLE_NAME = "ID_TABLE_NAME" as const;
+export const ID_TABLE_NAME_LIFF_APP = "ID_TABLE_NAME_LIFF_APP" as const;
export const ID_BUCKET_NAME = "ID_BUCKET_NAME" as const;
:
export const ID_MEMO_STORE_REPOSITORY = "ID_MEMO_STORE_REPOSITORY" as const;
+export const ID_IMAGE_CRAFT_REPOSITORY = "ID_IMAGE_CRAFT_REPOSITORY" as const;
+export const ID_LINE_BOT_USE_CASE = "ID_LINE_BOT_USE_CASE" as const;
:
export const initContainer = (): Container => {
const container = new Container();
:
container
.bind(ID_TABLE_NAME)
.toDynamicValue(() => process.env.TABLE_NAME || "")
.inSingletonScope();
+ container
+ .bind(ID_TABLE_NAME_LIFF_APP)
+ .toDynamicValue(() => process.env.TABLE_NAME_LIFF_APP || "")
+ .inSingletonScope();
container
.bind(ID_BUCKET_NAME)
.toDynamicValue(() => process.env.BUCKET_NAME || "")
.inSingletonScope();
:
container
.bind(ID_IMAGE_CRAFT_REPOSITORY)
.toDynamicValue((context) =>
new ImageCraftRepositoryImpl({
bedrock: context.container.get<BedrockRuntimeClient>(ID_BEDROCK),
s3: context.container.get<S3Client>(ID_S3),
bucketName: context.container.get<string>(ID_BUCKET_NAME),
})
)
.inSingletonScope();
+ container
+ .bind(ID_FRIEND_REGISTER_REPOSITORY)
+ .toDynamicValue((context) =>
+ new FriendRegisterDBRepository({
+ dbDocument: context.container.get<DynamoDBDocumentClient>(ID_DYNAMODB_DOCUMENT),
+ friendRegisterTableName: context.container.get<string>(ID_TABLE_NAME_LIFF_APP),
+ })
+ )
+ .inSingletonScope();
// LINE ボットユースケース
container
.bind(ID_LINE_BOT_USE_CASE)
.toDynamicValue(async (context) =>
execLineBotUseCase({
lineBotClient: await context.container.getAsync<LineBotImpl>(ID_LINE_BOT),
memoStoreRepository: context.container.get<MemoStoreDynamoDBRepository>(ID_MEMO_STORE_REPOSITORY),
imageCraftRepository: context.container.get<ImageCraftRepositoryImpl>(ID_IMAGE_CRAFT_REPOSITORY),
friendRegisterRepository: context.container.get<FriendRegisterDBRepository>(ID_FRIEND_REGISTER_REPOSITORY),
})
)
.inSingletonScope();
+ container
+ .bind(ID_REGISTER_API_USE_CASE)
+ .toDynamicValue((context) =>
+ execRegisterApiUseCase({
+ friendRegisterRepository: context.container.get<FriendRegisterDBRepository>(ID_FRIEND_REGISTER_REPOSITORY),
+ })
+ )
+ .inSingletonScope();
return container;
}
ボット側の実装
ボット側もユースケースにて友だち登録時に新規かブロック解除で判断している箇所を登録元を DynamoDB から取得しユーザへ pushMessage します。
ブロック時はそのデータを削除するようにしています(これは今回のサンプルの仕様ということで)。
ユースケース、ディスパッチ実行時に FriendRegister テーブルのリポジトリを取得し、データ取得、削除を行います。
ソースはたたんでおきます。
import { execRegisterCommand, execListCommand, execDeleteCommand, execAskCommand, ReplyMessage, ReplyMessages } from "./dispatchCommand";
+import { FriendRegisterRepository} from "@/domain/model/friendRegister/friendRegister-repository";
:
+const dispatchFollowEvent = async ({
+ webhookEvent,
+ friendRegisterRepository,
+}: {
+ webhookEvent: WebhookEvent,
+ friendRegisterRepository: FriendRegisterRepository,
+}): Promise<ReplyMessage | undefined> => {
+ const lineUserId = webhookEvent.source.userId || "";
+ const friendRegister = await friendRegisterRepository.getItemFromId(lineUserId);
+ const fromPlace = (friendRegister != null) ? friendRegister.registerFrom : "通常の友だち登録";
+ if (webhookEvent.type === "follow") {
+ const followEvent: FollowEvent = (webhookEvent as FollowEvent);
+ if (followEvent.follow != null) {
+ if (friendRegister != null) {
+ await friendRegisterRepository.putItem({
+ lineUserId: friendRegister.lineUserId,
+ registerFrom: friendRegister.registerFrom,
+ registerStatus: "FRIEND REGISTERED",
+ });
+ }
+ // アンロックからの復帰応答を設定
+ if (followEvent.follow.isUnblocked === true) {
+ return {
+ type: "text",
+ text: `${fromPlace} より、おかえりなさいませ!`,
+ };
+ // それ以外は新規追加としてイベント応答を設定
+ } else {
+ return {
+ type: "text",
+ text: `${fromPlace} より、いらっしゃいませ!`,
+ };
+ }
+ }
+ } else {
+ friendRegisterRepository.deleteItem(lineUserId);
+ }
+ return undefined
+}
:
/**
* event ディスパッチ処理
* @param webhookEvent : event インスタンス
* @param lineBotClient : LINE の Messaging API 等実行インスタンス
* @param memoStoreRepository : memoStore データリポジトリ
* @param imageCraftRepository : imageCraft データリポジトリ
+* @param friendRegisterRepository : FriendRegister データリポジトリ
* @returns ExecWebhookEventResult 戻り値(エラーインスタンス、エラー無しの場合は undefined)
*/
const dispatchEvent = async({
:
const commandResult: ReplyMessages = [];
// フォローイベント
- if (webhookEvent.type === "follow") {
- const followEvent: FollowEvent = (webhookEvent as FollowEvent);
- if (followEvent.follow != null) {
- if (followEvent.follow.isUnblocked === true) {
- commandResult.push({
- type: "text",
- text: "おかえりなさいませ!",
- });
- } else {
- commandResult.push({
- type: "text",
- text: "いらっしゃいませ!",
- });
- }
- }
+ if (webhookEvent.type === "follow" || webhookEvent.type === "unfollow") {
+ const replyMessage = await dispatchFollowEvent({ webhookEvent, friendRegisterRepository });
+ if (replyMessage != null) {
+ console.log(replyMessage);
+ commandResult.push(replyMessage);
+ }
} else if (webhookEvent.type === "message" && webhookEvent.message.type === "text") {
:
以上より、構成は下記になります。
※+
が追加したソース、-
が編集したソースです。
/
└─ backend
└─ src
├─ di-container
- │ └─ register-container.ts
├─ domain
│ ├─ model
+ │ │ ├─ friendRegister
+ │ │ │ ├─ friendRegister-repository.ts
+ │ │ │ └─ friendRegister.ts
│ │ ├─ imageCraft
│ │ │ └─ imageCraft-repository.ts
│ │ └─ memoStore
│ │ ├─ memoStore-repository.ts
│ │ └─ memoStore.ts
│ └─ support
│ └─ line-bot
│ └─ line-bot.ts
├─ handler
+ │ ├─ liff-bot
+ │ │ └─ handler-register.ts
│ └─ line-bot
│ └─ line-bot.ts
├─ infrastracture
│ ├─ line-bot
│ │ └─ line-bot-impl.ts
│ └─ repository
+ │ ├─ friendRegister-dynamodb-repository.ts
│ ├─ imageCraft-bedrock-s3-repository.ts
│ └─ memoStore-dynamodb-repository.ts
└─ use-case
+ ├─ liff-app-use-case
+ │ └─ use-case.ts
└─ line-bot-use-case
├─ dispatchCommand.ts
- └─ use-case.ts
動作確認
操作方法
1.LINE Developers から行けるコンソールより、対象の LINE ミニアプリのメニューまで行き、LIFF タグにある URL(下記の赤枠)をブラウザで開きます。
2.ブラウザで開くと下記のように QR コードが画面表示されますので、URL の入力に ?registeredSource=経路テスト
と入力(パラメータの値は任意です)してページを開きます。
3.改めて開き直される QR コードをスキャンしてミニアプリを起動し、LINE へ友だち登録
ボタン(赤枠)を押下します。
4.その後は友だち登録の画面になりますので赤枠で囲った部分のどちらかをタップ。
新規登録
新規登録をしたときのボットメッセージです。
ブロック解除
ブロック解除のときのボットメッセージです。
経路無しで通常のブロック解除したときはこうなります。
おわりに
今回は LINE の友だち登録における追加経路を、マニュアルで操作する方法について記事にしてみました。
課題としては、登録やブロック解除を確認する画面でキャンセル(LINE の友だち登録画面で右上の✕をタップ)すると、そのイベントをミニアプリでも Webhook でも拾えないので保持したデータが残ったままになります。
しかし、うまく誘導できれば起こりにくいケースでもあるので、今回実装してみた形で行うのも手かと思います。
そして、友だち登録の URL に付加したクエリパラメータを Webhook にまで引き継がれる仕組みが実現されることを気長に待ちたいと思います。
この記事が皆さまの参考になれば幸いです。
アノテーション株式会社について
アノテーション株式会社はクラスメソッドグループのオペレーション専門特化企業です。
サポート・運用・開発保守・情シス・バックオフィスの専門チームが、最新 IT テクノロジー、高い技術力、蓄積されたノウハウをフル活用し、お客様の課題解決を行っています。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、アノテーション株式会社 採用サイトをぜひご覧ください。